本節是以 Golang 上游 ee91bb83198f61aa8f26c3100ca7558d302c0a98 為基準做的實驗。
予焦啦!昨日不幸地卡在一個弔詭之處:整支程式的初始化就是一行一行照著走,但為什麼存在記憶體內的資料卻不能正常使用呢?沒錯,有個關鍵的初始化我們還沒有做的,那就是 BSS 區段的相關處理。
.bss
區段.bss
:未初始化資料區昨日,最令我們困惑的部分是,一開始存在的內容似乎就已經註定讓我們走向錯誤,但事實上有個盲點:那些資料存在記憶體內,但它們未必是應該存在的。
我們可以使用 readelf 工具再看看 g0
與 m0
這兩個東西:
$ riscv64-buildroot-linux-musl-readelf -s ethanol/ethanol
...
1004: ffffff80000aa310 16 OBJECT GLOBAL DEFAULT 10 runtime.modinfo
1005: ffffff80000ac040 976 OBJECT GLOBAL DEFAULT 11 runtime.m0
1006: ffffff80000abea0 392 OBJECT GLOBAL DEFAULT 11 runtime.g0
1007: ffffff80000d6770 8 OBJECT GLOBAL DEFAULT 12 runtime.mcache0
...
實際上,這兩個符號所代表的結構處在同一個、我們未曾探討過的區段。使用 readelf 工具觀察區段資訊:
$ riscv64-buildroot-linux-musl-readelf -S ethanol/ethanol
...
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[ 9] .noptrdata PROGBITS ffffff80000a9020 000a8020
00000000000012a0 0000000000000000 WA 0 0 32
[10] .data PROGBITS ffffff80000aa2c0 000a92c0
0000000000001990 0000000000000000 WA 0 0 32
[11] .bss NOBITS ffffff80000abc60 000aac60
000000000002aa08 0000000000000000 WA 0 0 32
[12] .noptrbss NOBITS ffffff80000d6680 000d5680
0000000000004c00 0000000000000000 WA 0 0 32
...
每個區段都標示了所屬的位址(Address
欄位)。所以我們可以對照出,g0
所屬的 0xffffff80000abea0
其實位在 .bss
區段,因為 .bss
區段從 0xffffff80000abc60
開始,且大小為 0x2aa08
(Size
欄位),完全涵蓋 g0
的位址。事實上,它也完全涵蓋 m0
的 0xffffff80000ac040
。
關於 .bss 區段的說明,可以參考維基。
在執行檔或是物件檔中,.bss
區段用來存放未賦予初值的資料。g0
與 m0
在 Golang 程式碼中都沒有被賦予初值,所以歸屬在這個區段裡面。這個區段的類型(Type
欄位)被標記為 NOBITS
,意味著,這個區段的內容可以在動態執行時期才配置,相反的,這個區段在檔案當中可以不存在。
一般而言 C 語言程式的產物的 .bss 在 ELF 檔案中完全不會佔據空間,但是 Golang 程式還是會佔據那些空間,所以從 readelf 的 偏移量(
offset
)欄位還是可以發現到變化。
無論如何,既然 g0
與 m0
是未初始值,而 Golang 的慣例又是未初始變數一定有 0 值,那麼我們就必須將之清除為 0 了。這有三個層面,
go build
指令的產物(ethanol/ethanol 執行檔)中,相對應於 .bss
的偏移的部分是否含有內容?-device loader ...
參數項目實際上如何載入整個 ethanol 映像檔?.bss
?其實,.bss
或是 .noptrbss
在 Golang 執行檔中都不佔據實體空間,這可以從幾個角度驗證。第一個是 readelf 工具給予我們的區段資訊:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[11] .bss NOBITS ffffff80000abc60 000aac60
000000000002aa08 0000000000000000 WA 0 0 32
[12] .noptrbss NOBITS ffffff80000d6680 000d5680
0000000000004c00 0000000000000000 WA 0 0 32
...
類型欄位(Type
)中的 NOBITS
值就代表,這些區段在檔案中沒有實質內容。第二個是載入時的區段:
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0xffffff8000001040 0xffffff8000001040
0x0000000000000118 0x0000000000000118 R 0x1000
NOTE 0x0000000000000f9c 0xffffff8000001f9c 0xffffff8000001f9c
0x0000000000000064 0x0000000000000064 R 0x4
LOAD 0x0000000000000000 0xffffff8000001000 0xffffff8000001000
0x0000000000055104 0x0000000000055104 R E 0x1000
LOAD 0x0000000000056000 0xffffff8000057000 0xffffff8000057000
0x0000000000051ef8 0x0000000000051ef8 R 0x1000
LOAD 0x00000000000a8000 0xffffff80000a9000 0xffffff80000a9000
0x0000000000002c60 0x0000000000032280 RW 0x1000
Section to Segment mapping:
Segment Sections...
00
01 .note.go.buildid
02 .text .note.go.buildid
03 .rodata .typelink .itablink .gosymtab .gopclntab
04 .go.buildinfo .noptrdata .data .bss .noptrbss
這裡可以看到 .bss
與 .noptrbss
對應到第五個區塊(segment),而第五個區塊也是唯一一個檔案中大小(欄位 FileSiz
)與記憶體大小(欄位 MemSiz
)不一樣的。
又,也可以使用 objdump 工具觀察
$ riscv64-buildroot-linux-musl-objdump -h hw
...
hw: file format elf64-littleriscv
Sections:
Idx Name Size VMA LMA File off Algn
...
8 .data 00001990 ffffff80000aa2c0 ffffff80000aa2c0 000a92c0 2**5
CONTENTS, ALLOC, LOAD, DATA
9 .bss 0002aa08 ffffff80000abc60 ffffff80000abc60 000aac60 2**5
ALLOC
10 .noptrbss 00004c00 ffffff80000d6680 ffffff80000d6680 000d5680 2**5
ALLOC
...
以 .data
區段做對照,則可發現 .bss
與 .noptrbss
都具有特殊的 ALLOC
屬性。
QEMU 的 loader 支援三種主要功能,這裡有精簡的描述。
我們在做實驗時主要採用 QEMU 的通用載入器(generic loader)來將檔案或資料放置到指定的位址。由於我們在載入 ethanol 映像檔的時候強迫開啟了原始格式(force-raw = true
),所以是整個檔案載入。若是沒有開啟原始格式,則這個檔案會被當作 ELF 檔載入。
如前一小節的敘述,雖然檔案內沒有實質對應到 .bss
或 noptrbss
的內容,但是現在這個原始格式的載入器,會按照檔案位置如實地載入。所以我們可以回顧當初存取 m0
內容的時候,當時是存取 0x802ac0e0
這個位址,也就是以檔案內偏移量來算的 0xab0e0
的位置,實際上它會對應到:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[11] .bss NOBITS ffffff80000abc60 000aac60
000000000002aa08 0000000000000000 WA 0 0 32
[12] .noptrbss NOBITS ffffff80000d6680 000d5680
0000000000004c00 0000000000000000 WA 0 0 32
[13] .zdebug_abbrev PROGBITS ffffff80000dc000 000ab000
0000000000000119 0000000000000000 0 0 1
[14] .zdebug_line PROGBITS ffffff80000dc119 000ab119
000000000000e7e2 0000000000000000 0 0 1
...
.zdebug_abbrev
區段裡面,且使用 hexdump 工具檢驗看看:
$ hexdump -C hw
...
000ab0e0 59 1f d6 e0 5b e5 e0 74 28 46 bf 0b 67 a5 44 a3 |Y...[..t(F..g.D.|
...
正是我們昨日觀察到的,錯誤記憶體存取的基底!總之是一個不該被拿來當作記憶體位址的誇張奇異值。
所以也許我們別開啟原始格式就足夠了?但下一節才是最踏實的方案。
以其他系統軟體為例,比方說前文中提過的 Linux 與 OpenSBI,都會在相當早期的階段清除 BSS 的內容。我們也應該這麼做:
diff --git a/src/runtime/rt0_opensbi_riscv64.s b/src/runtime/rt0_opensbi_riscv64.s
index c65afa5c79..abaf4ba280 100644
--- a/src/runtime/rt0_opensbi_riscv64.s
+++ b/src/runtime/rt0_opensbi_riscv64.s
@@ -72,5 +72,12 @@ TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
JMP main(SB)
TEXT main(SB),NOSPLIT|NOFRAME,$0
+ MOV $runtime bss(SB), T0
+ MOV $runtime enoptrbss(SB), T1
+zeroize:
+ SD ZERO, 0(T0)
+ ADD $8, T0, T0
+ BLT T0, T1, zeroize
+
MOV $runtime rt0_go(SB), T0
重新執行的話,得到
...
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000a109
HI0000000000000005
ffffff8000075d00
0000000080245ae8
QEMU: Terminated
仍然一樣撞到記憶體存取錯誤,但卡在不同地方了。觸發錯誤的位置在
ffffff8000045ac0 <runtime.moduledataverify1>:
ffffff8000045ac0: 010db503 ld a0,16(s11)
ffffff8000045ac4: fb810593 addi a1,sp,-72
ffffff8000045ac8: 00b56863 bltu a0,a1,ffffff8000045ad8 <runtime.moduleda
taverify1+0x18>
ffffff8000045acc: 0000df97 auipc t6,0xd
ffffff8000045ad0: b84f82e7 jalr t0,-1148(t6) # ffffff8000052650 <runtime
.morestack_noctxt>
ffffff8000045ad4: fedff06f j ffffff8000045ac0 <runtime.moduledataveri
fy1>
ffffff8000045ad8: f2113c23 sd ra,-200(sp)
ffffff8000045adc: f3810113 addi sp,sp,-200
ffffff8000045ae0: 0d013183 ld gp,208(sp)
ffffff8000045ae4: 0001b383 ld t2,0(gp)
ffffff8000045ae8: 0003e403 lwu s0,0(t2)
而錯誤正是發生在最後這一行的 t2
暫存器中存放了 ffffff8000075d00
數值。看起來有點眼熟,對吧?是的,它不只是長得像而已,它實際上就是一個當前 ethanol 映像檔中的一個虛擬位址,而且是一個區段的開頭:
$ riscv64-buildroot-linux-musl-readelf -a hw
...
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[ 7] .gopclntab PROGBITS ffffff8000075d00 00074d00
00000000000331f8 0000000000000000 A 0 0 32
...
Symbol table '.symtab' contains 1089 entries:
Num: Value Size Type Bind Vis Ndx Name
...
118: ffffff8000075ce8 8 OBJECT LOCAL DEFAULT 5 runtime.itablink
119: ffffff8000075d00 0 OBJECT LOCAL DEFAULT 7 runtime.pclntab
120: ffffff8000075240 1686 OBJECT LOCAL DEFAULT 2 runtime.findfunctab
...
相關的程式碼位在 src/runtime/symbol.go
當中,
func moduledataverify1(datap *moduledata) { // Check that the pclntab's format is valid.
hdr := datap.pcHeader
if hdr.magic != 0xfffffffa || hdr.pad1 != 0 || hdr.pad2 != 0 || hdr.minLC != sys.PCQuantum || hdr.ptrSize != sys.PtrSize {
...
回顧一下反組譯的結果。首先是堆疊指標(sp
)偏移 0x208
之後的值存在 gp
暫存器,接著以 gp
暫存器作為基底取值存在 t2
,然後最後 t2
的內容是我們還不能使用的虛擬位址指標,所以出錯。
這個行為對應到傳入參數 datap
,這相當於是 gp
的值;datap.pcHeader
這一項取得成員的動作就相當於是 t2
,之後的 hdr.magic
就是事故現場。那麼,為什麼最一開始會有那樣的值存入呢?
var firstmoduledata moduledata // linker symbol
var lastmoduledatap *moduledata // linker symbol
var modulesSlice *[]*moduledata // see activeModules
...
func moduledataverify() {
for datap := &firstmoduledata; datap != nil; datap = datap.next {
moduledataverify1(datap)
}
}
可見,源頭的 firstmoduledata
符號本身,是連結器在連結時期已經綁定到虛擬位址的符號;這個定址方式是絕對的,直接指定為 0xffffff8000075d00
。相較於我們從一開始進入 ethanol/ethanol 到出現錯誤,期間經歷許多變數存取與函數呼叫,之所以不會遇到一樣的問題,是因為目前為止遭遇的符號都使用了相對程式指標定址(PC-related addressing),所以就算現在我們是被載入在實體的物理位址之上,也還是能夠正確地在執行時期存取到對的函數或是變數。
也就是說,firstmoduledata
裡面的內容,必須直接用到連結時就規定好的絕對位址;以 ethanol 核心的角度來講,是一個還沒有啟用的虛擬位址。連結器對於執行期會被載入到哪裡去,當然是沒有概念的,所以這裡我們遇到了一道更高的牆了。
是否已經到了啟用虛擬記憶體的時機了呢?這邊我們暫且打住,回頭檢視本日最一開始的問題。
也許諸位讀者還是會感到疑惑:原先是因為讀取到髒值而導致存取錯誤,那麼為什麼將髒值清成零能夠解決問題呢?難道不會因為存取 0 作為指標而產生一樣的錯誤嗎?
這就必須正視原本這個出問題的部分的寫入屏障機制。我們回顧昨日展示的執行追蹤:
(gdb) bt
#0 0x0000000080254510 in runtime.gcWriteBarrier ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:653
#1 0x000000008023ebd8 in runtime.args (c=-2145037200, v=0x82200000)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/runtime1.go:63
#2 0x00000000802524b4 in runtime.rt0_go ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:54
可知是由 args
函式呼叫 gcWriteBarrier
函式,然而若我們查看原始碼,會發現
func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}
根本就沒有這個呼叫!這完全是 Golang 編譯器擅作主張插入的內容,我們可以檢視反組譯的結果:
ffffff800003eba0: 00098f97 auipc t6,0x98
ffffff800003eba4: ae3fa823 sw gp,-1296(t6) # ffffff80000d6690 <runtime
.argc>
ffffff800003eba8: 00098197 auipc gp,0x98
ffffff800003ebac: c881e183 lwu gp,-888(gp) # ffffff80000d6830 <runtime.writeBarrier>
ffffff800003ebb0: 00019a63 bnez gp,ffffff800003ebc4 <runtime.args+0x44>
ffffff800003ebb4: 01813183 ld gp,24(sp)
ffffff800003ebb8: 0006df97 auipc t6,0x6d
ffffff800003ebbc: 0c3fb823 sd gp,208(t6) # ffffff80000abc88 <runtime.argv>
ffffff800003ebc0: 0180006f j ffffff800003ebd8 <runtime.args+0x58>
ffffff800003ebc4: 0006d297 auipc t0,0x6d
ffffff800003ebc8: 0c428293 addi t0,t0,196 # ffffff80000abc88 <runtime.argv>
ffffff800003ebcc: 01813303 ld t1,24(sp)
ffffff800003ebd0: 00016f97 auipc t6,0x16
ffffff800003ebd4: 920f80e7 jalr -1760(t6) # ffffff80000544f0 <runtime.gcWriteBarrier>
ffffff800003ebd8: 00013083 ld ra,0(sp)
ffffff800003ebdc: 00810113 addi sp,sp,8
ffffff800003ebe0: 00008067 ret
關鍵在於 ffffff800003ebac
基於 WriteBarrier
的判斷。這裡組語指令是一個載入 4 個位元組的無號整數(lwu
)並判斷它是否為零(bnez
),其實是因為對應到這個位址的內容代表的是垃圾回收機制的寫入屏障是否啟動(enabled)。若是已經啓動,才去執行之前發生問題的 gcWriteBarrier
。
也就是說我們可以總結原本遭遇的錯誤了。這個用來判斷的旗標,其實本身也是處在 .bss
區段中的變數,由於未清除的緣故,觸發了還不應該使用的 Golang 垃圾回收機制。觸發了之後,沿著寫入屏障函數一路深入,最後終於存取到了未清除初值的結構體,因此引發錯誤。
整個 Golang 執行期提供如此豐富的功能給一般的使用者程式,我們怎麼能夠期待現在垃圾回收以及其他機制已經能夠提供我們使用了呢?
Golang 的垃圾回收機制從編譯(compile)與連結(link)時期就開始佈局了,所以最終的產物裡面,會像這樣看到寫入屏障的安插。當記憶體的改動累積到一定程度之後,Golang 的執行期必須要抽空執行垃圾回收演算法,以回收被棄用的指標或空間等等。我們目前還沒有必要深入這個部分,但可以先理解到這個機制的存在。
予焦啦!今日我們做了一個小幅修正,確保 .bss
等未初始化的資料區段能夠正確的被初始化。我們又推進了一小步,但也馬上就又卡住了,而且這次的起因是,連結器符號所代表的結構體當中存放了虛擬記憶體位址。
於是,本章也應該要宣告結束了。筆者目前為止已經展示了基本的除錯技法、土法煉鋼也要往前爬的姿態以及暴虎馮河之心。無論如何,接下來我們也應該跨出新的步伐了,那就是:啟用虛擬記憶體。
說得更精煉一些:由於 Golang RISC-V 沒有提供建置位置非相依可執行檔(PIE,Position-Independent Executable)的選項,所以遭遇到絕對定址之後,我們就束手無策了。程式不可能按照原本規劃的那樣進行下去,因為目前為止都還是靠著程式指標相對定址搭配載入時期載入在實體物理位址的條件下執行的。為了解決這個現狀,也是時候該啟用虛擬記憶體了。
各位讀者,我們明日再會!